Skip to content

feat(spec): ADR-0112 error envelope gains a producer-side refusal declaration so a deliberate 5xx refusal keeps its caller-authored message (#16335) - #17090

Draft
os-bill wants to merge 14 commits into
mainfrom
claude/issue-16335-adr-0112-refusal-declaration
Draft

feat(spec): ADR-0112 error envelope gains a producer-side refusal declaration so a deliberate 5xx refusal keeps its caller-authored message (#16335)#17090
os-bill wants to merge 14 commits into
mainfrom
claude/issue-16335-adr-0112-refusal-declaration

Conversation

@os-bill

@os-bill os-bill commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Fixes #16335

Clause-②: yes

Spec half of #16146, under director decision batch #58 (2026-09-06, option C; maintainer 「同意」): the refusal/fault distinction is a producer-side declaration on the published ADR-0112 envelope — not a status heuristic and not a second allow-list. This PR declares that field. The relay half — the withhold arms learning to read it, plus retiring the route-local patch from PR #16143 — is carried by #16146 for the @objectstack/rest pair and by its sub-issue #17153 for the @objectstack/runtime dispatcher exit. ⛔ packages/rest/**, packages/runtime/** and packages/types/** are untouched here.

What changed

  • packages/spec/src/api/contract.zod.tsApiErrorSchema gains one optional key, refusal: z.literal(true).optional(), with a TSDoc that (a) documents the three cases side by side, (b) states the semantics and names all three withhold arms the relay must move, and (c) reconciles the flag with the userMessage design note at :77-83.
  • packages/spec/src/api/errors.zod.tsEnhancedApiErrorSchema gains the same key, its TSDoc pointing at the base for the rationale (the userMessage pattern from contract: a hook refusal has no way to mark its message user-facing — the console's 403 substitution (ruled in #3821) needs a producer-side opt-in channel #9934).
  • Both .describe() strings carry the relay caveat in present-tense fact form — "until the withhold arms read it, a declared refusal is still withheld" — so the 66 generated reference rows and the two shipped JSON schemas do not advertise a reader that does not exist yet. The caveat carries no issue id: check:doc-authoring refuses one in customer-facing prose.
  • content/docs/api/error-catalog.mdx — the hand-written interface EnhancedApiError block gains refusal?: true;. This block is authored, not generated, so the 66 generated rows never reached it (director finding F2).
  • Pins in contract.test.ts and errors.test.ts: kept verbatim beside message; absent by default; refusal: false refused at ['refusal'] with invalid_value; a non-boolean refused; orthogonal to userMessage; the docblock's promise that a base-envelope field reaches the downstream ledger through makeApiErrorSchema; and a z.toJSONSchema pin on each envelope asserting the shipped grammar is {"type":"boolean","const":true}.
  • .changeset/adr-0112-envelope-refusal-declaration.md@objectstack/spec: minor.
  • Regenerated: packages/spec/authorable-surface/api.json (+2 rows) and 11 pages under content/docs/references/api/.

The THREE withhold arms — and the third is outside @objectstack/rest

The at-tier contract review's binding item B1': an earlier draft called the two @objectstack/rest arms a closed set, in prose that ships (packages/spec's files[] carries src/**/*.zod.ts, so this TSDoc goes into the tarball verbatim). It is not closed. Measured on this tree:

  1. @objectstack/rest declaredServerFaultAnswer (error-response.ts) — its only two callers are the /data classifier (rest: a declared 5xx answers 500 ANALYTICS_QUERY_FAILED on /analytics/dataset/query where /data answers the declared status and code #11718), which wraps it in withDeclaredUserMessage, and the analytics dataset door, which calls it bare, so userMessage rides the first and not the second;
  2. @objectstack/rest resolveErrorResponse's own 5xx passthrough arm, reached through handleRouteError / sendThrownError. The /references throw spells status, so it takes that function's status passthrough into this arm; a statusCode-spelled 5xx falls to mapDataError and arm 1 instead. Arms 1 and 2 compose the same bytes;
  3. @objectstack/runtime errorResponseBase (dispatcher-plugin.ts), the kernel dispatcher plugin's thrown exit, gated on serverFaultProvenance(thrown) === 'declared' (@objectstack/types) — any 5xx whose throw declared a status, code or not. objectstack serve mounts it via createDispatcherPlugin; it answers POST /analytics/query; it emits ErrorResponseSchema, the very envelope EnhancedApiErrorSchema describes; and it is pinned by dispatcher-plugin.declared-5xx-prose-withhold.test.ts (runtime: a declared 5xx carrying NO code keeps its prose on /analytics/query where /data withholds it unconditionally #12281). It never consults arm 1, so moving the REST pair alone leaves it withholding.

Two readings behind arm 3, both verified here rather than taken on report: serverFaultProvenance returns 'declared' whenever declaredStatus !== undefined, and resolveThrownHttpError sets declaredStatus from e.status or e.statusCode — hence "a declared status or statusCode" in the TSDoc. The TSDoc also states explicitly that the table's second row is the rule at these three arms, not a platform-wide invariant: every other 5xx door (HttpDispatcher's returned exit, endpointErrorAnswer, package-routes' sendThrownError, the hono auth door) reads no declaration and withholds by the leak heuristic alone.

Acceptance grep for B1', on this head:

git show HEAD:packages/spec/src/api/contract.zod.ts | sed -n '93,186p' | grep -cP 'dispatcher|errorResponseBase'
   pre-round e7305600 -> 0        this head -> 3        (required: >= 1)
   lit control 'withhold' in the same range: 6 -> 8

The rewrap survey — no count, and the carry* site named

The earlier draft's "13 Object.assign sites" is gone: the review counted 20 on two trees with its own spelling, so a census number written into shipped prose rots. The TSDoc now states the shape and names the sites, with the fail-closed conclusion unchanged:

  • The QuickJS door carries a CLOSED list out of the VM (SANDBOX_ERROR_PASSTHROUGH) and this field is not on it.
  • Of the Object.assign error-composition sites under packages/** (non-test), the ones that copy anything off a caught error copy only code and/or causedrivers/driver-sql, and objectql's autonumber-collision rewrap, whose code is a literal and whose only copy off the caught error is cause — and never status.
  • The two overlay-delete rewraps in metadata-protocol/src/protocol.ts DO copy status (with a 500 fallback) onto a rewritten message and carry code and userMessage through carryCatalogedErrorCode / carryDeclaredUserMessage. Nothing carries this flag, so a refusal crossing them is withheld as a fault — and the TSDoc now records that ⛔ the relay must not add a carryRefusal there, since that would put overlayDeleteFailureMessage's platform prose on the flag channel, the promotion the userMessage note refused.
  • The one in-place message rewrite that keeps status/code (runtime/src/domains/actions.ts) is on a SandboxError, which cannot carry the flag.

What the checked-in baselines see — corrected

The earlier draft claimed the pins were the only in-tree evidence of the value rule. That was wrong, and it is the inverse of this lane's failure mode: it described a green gate that IS answering the question as one that is not.

Four baselines are indeed blind to const: trueauthorable-surface/ records key names, api-surface/ export names, json-schema.manifest/ schema names, and the liveness ledger is the api metadata type's. But a fifth checked-in artifact is not blind: build-docs.ts renders the type column from prop.const, so all 66 generated reference rows read `true`. check:docs (build-docs.ts --check, exit 1 on drift) is a required status check with no paths filter. Downgrading z.literal(true) to z.boolean() therefore either reds that required check or shows up as a 66-row `true``boolean` diff in review. Plus, now, the two z.toJSONSchema pins added this round assert the shipped {"type":"boolean","const":true} directly.

Reconciliation with contract.zod.ts:77-83

The #9934 note refuses "a boolean beside message" for the END-USER channel: that channel's consumer is the console renderer across the wire, #3821's generic substitution must hold against every boundary that rewraps message, and a mark separated from its text could be promoted onto platform prose. That argument does not transfer here:

  1. It qualifies the STATUS declaration, not a text. The flag says "the 5xx I declared is a refusal", the way code already qualifies status for declaresServerFault. Its only consumers are the three withhold arms, each reading status, code and the flag off the SAME thrown object in ONE read before composing a body. There is no second channel to promote prose into; the flag only switches the withhold off, and what reaches the wire is the same message a declared 4xx already discloses.
  2. A rewrap is fail-closed — the survey above.
  3. Shape follows the ruling's own words. The recorded ruling on [finding] declaredServerFaultAnswer cannot tell a deliberate REFUSAL from a server FAULT, so every producer-declared 5xx refusal loses its prose on the wire #16146 (comment 5559950291) says the relay "keeps the message verbatim only when it is present and withholds it otherwise" — presence is the declaration. true is therefore the only value: a fault already has its own spelling, so refusal: false fails parse rather than becoming a third state every consumer would have to interpret.

If a reader judges that (1)-(3) do not survive, the fork is: keep the producer-side declaration but carry the text (a refusalMessage-shaped field), which would make message on a 501 stay INTERNAL_ERROR_MESSAGE and move the prose to a sibling — the opposite of what the card asks. I did not take that fork; it is named so the choice is visible.

Verification

All readings on the final head 71e8db06a (origin/main 4261fbc80 merged through scripts/pm/os-regen-merge.sh). Heavy runs went through scripts/pm/os-verify-lock.sh; every exit code was redirected to disk before it was read.

  • Tests — the FULL package suite, not a subset: pnpm --filter @objectstack/spec testTest Files 470 passed (470), Tests 13207 passed (13207), TEST_EXIT=0.
  • Typecheckpnpm --filter @objectstack/spec typecheckTYPECHECK_EXIT=0, including check:test-typecheck over the test layer (54 files / 261 errors / 145 pinned signatures held in the shrink-only ledger).
  • Build + regenerationpnpm --filter @objectstack/spec build && gen:docs && check:generated under one lock hold: ✓ All 15 generated artifacts are up to date, VERDICT command-exit 0. Working tree clean afterwards, so the merge introduced no regeneration drift.
  • Derived gatesnode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 103 families on this head; all 103 were run, each exit code written to disk first, then reconciled: ✓ dispatch-gates --ran: 103 derived famil(ies) accounted for — 103 run, 0 NOT-MEASURED (exit 0). 101 measured green. 2 ran but measured nothing and are reported as NOT MEASURED, never as passes: check:dual-build-cjs-loads and check:type-check-debt, both exit 3 with the gate's own PREREQUISITE NOT MET line ("⛔ This is NOT a pass: nothing was measured") because each needs a whole-workspace build. Those are CI's run.
  • Lint, narrowed with the three-part proof: (1) population read from eslint.config.mjs itself — files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']; (2) file count read from --format json: 4 files, 0 errors, 0 warnings, exit 0, over the 4 TS files this diff touches; (3) invariance — every parserOptions block in the config is {ecmaVersion, sourceType} only, with no project and no projectService, and the config states at :328 that no typed @typescript-eslint rules run for any file, backed by its own planted positive control. With no type-aware linting, this diff cannot move the verdict on any file it does not touch. The repo-wide pnpm lint is CI's run.

验收备注

Authored in Claude Code session session_01MkQhmuuJAVDjmeWNixwDDH (patch round; the contract-review seat's carrier needs:contract-review stays on, untouched).


Generated by Claude Code

…eclaration

`ApiErrorSchema` and `EnhancedApiErrorSchema` declare one new optional key,
`refusal: true` — the producer's declaration that the 5xx it named is a
deliberate refusal whose `message` is authored for the caller, so the boundary
keeps it verbatim (decision batch #58, option C). The TSDoc documents the
three cases side by side (undeclared 5xx: heuristic; declared fault: withheld;
declared refusal: kept) and reconciles the flag with the recorded reason
`userMessage` is a text-carrying field rather than a boolean beside `message`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…efusal`

`pnpm --filter @objectstack/spec check:generated` on 9870511 read 14 of 15
artifacts fresh and named `content/docs/references/**` stale; `gen:docs` is
the one regeneration. `authorable-surface/api.json` gains the two key rows
(`api/ApiError:refusal`, `api/EnhancedApiError:refusal`) the build's
`gen:schema` wrote; `api-surface/` and `json-schema.manifest/` are zero-diff
by construction (export and schema names only).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
os-regen-merge.sh step 2 took origin/main's side of the generated
`content/docs/references/api/contract.mdx` (both sides had moved it — PR
#16783 on main, the `refusal` row here); the pre-commit collection point
then asked for `gen:schema && gen:docs` on the merged tree, which re-derives
the page with both sides' content.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
@github-actions github-actions Bot added the size/m label Sep 9, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 5 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via getReferences (sdk, the bare tail of client method meta.getReferences, bound to GET /api/v1/meta/:type/:name/references), meta.getReferences (sdk, the route ledger binds it to GET /api/v1/meta/:type/:name/references, selected by route anchor /meta/:type/:name/references))
  • content/docs/api/error-catalog.mdx (via ApiErrorSchema (symbol, a top-level const))
  • content/docs/api/index.mdx (via ApiErrorSchema (symbol, a top-level const))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via ApiErrorSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-1.mdx (via ApiErrorSchema (symbol, a top-level const), EnhancedApiErrorSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 134 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 3c5f3c5991fbf26693a0e99b70e76adaac11b4edpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 6f25a22d6e2054d66ad4f58953954d36bb025348 — the merge of head 71e8db06a6d879e97dd2fb7419298d1850af8ef5 into base 3c5f3c5991fbf26693a0e99b70e76adaac11b4ed, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6f25a22d6e2054d66ad4f58953954d36bb025348 && git checkout 6f25a22d6e2054d66ad4f58953954d36bb025348
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3c5f3c5991fbf26693a0e99b70e76adaac11b4ed 71e8db06a6d879e97dd2fb7419298d1850af8ef5 && git checkout -B drift-repro 3c5f3c5991fbf26693a0e99b70e76adaac11b4ed && git merge --no-ff 71e8db06a6d879e97dd2fb7419298d1850af8ef5

node scripts/docs-audit/affected-docs.mjs --json 3c5f3c5991fbf26693a0e99b70e76adaac11b4ed

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 3c5f3c5991fbf26693a0e99b70e76adaac11b4ed → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator

Contract review at CONTRACT_REVIEW_TIERVerdict: PASS WITH FINDINGS (audit reading; director seat, summon #18 segment 3, session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-09T08:5xZ)

PR #17090 · head ba3d95a4f3514243131a698f12589c23d49e6fcd (re-read at posting; unchanged) · reviewed-at 2026-09-09T08:34:33Z.

Findings

F1 (non-blocking)packages/spec/src/api/contract.zod.ts:175-180 and errors.zod.ts:401-405 — the .describe() strings assert present-tense wire behaviour ("so boundaries keep it verbatim") for a key that has no reader on this head: packages/rest/** is untouched, and declaredServerFaultAnswer (origin/main error-response.ts:574-588) still withholds every declared 5xx. The TSDoc paragraph (contract.zod.ts:133-137) and the changeset (.changeset/adr-0112-envelope-refusal-declaration.md:17) do disclose "until it lands, a declared refusal is still withheld at the wire" — but the .describe() text is what lands in the 11 generated reference pages (content/docs/references/api/contract.mdx:34 etc.) and in the shipped json-schema/api/*.json description, and a release cut between this merge and #16146 ships that sentence false. Not blocking: the split is director-ruled (#16146 comment 5559950291 sequences spec → relay), the inert state is fail-closed (the withhold stays on; no ADR-0049 regression), and the schema-level behaviour this PR does introduce is pinned. Fix: seat action — flip #16146 from pm:blocked the moment this merges and do not let an RC cut sit between them; if that cannot be guaranteed, append one clause to both .describe() strings ("relay: #16146") and regenerate the 11 pages (pnpm --filter @objectstack/spec gen:docs).

F2 (non-blocking)content/docs/api/error-catalog.mdx:663-679 — the hand-written interface EnhancedApiError excerpt (lint.yml:2310 confirms the page is hand-written) does not gain refusal?: true; the docs-drift bot named this page. Pre-existing drift: the same block already omits userMessage and declaredCode, so this PR did not create the gap. Fix: add refusal?: true; // producer-declared refusal; keeps message on a 5xx (#16335) after the message line, or file one docs-only follow-up covering all three omissions.

F3 (non-blocking) — PR body, Verification section, last bullet — literal placeholder check-clause2-carriers.mjs --pair PR_NUMBER. The real reading is on the card (report 5598012933: exit 4 → 0 after the card label) and the reviewer re-measured --pair 17090 → exit 0 ("both carriers agree"). Fix: replace PR_NUMBER with 17090.

1. Card ruling vs head

Ruling chain verified: #16146 comment 5559950291 (option C, batch #58, maintainer 「同意」) → "one optional field … keeps the message verbatim only when it is present". Head delivers presence-as-declaration via z.literal(true).optional() (contract.zod.ts:175, errors.zod.ts:401); refusal: false fails parse at ['refusal'] with invalid_value (zod ^4.4.3, packages/spec/package.json:324). Three-case table present at contract.zod.ts:105-109; userMessage placed orthogonal at :161-165. The dispatch's hard constraint (reconcile with the #9934 note at contract.zod.ts:77-83 or report a fork) is met head-on at :141-165; the fork is named in the PR body rather than taken. Fence: packages/rest/**, packages/types/**, content/docs/releases/** untouched (17-file list verified via git diff --name-status). The card's premise-narrowing (userMessage already carries caller prose on a 5xx; the gap is message disclosure only) is stated in the PR body and not walked past.

2. Accept-set movement (before → after)

Surface Before After Direction
ApiErrorSchema / EnhancedApiErrorSchema / makeApiErrorSchema(...) / ErrorResponseSchema.error — input without refusal accepted accepted, byte-identical output unchanged
same — input refusal: true accepted, key stripped (non-strict z.object, no .strict()/.passthrough() in either file) accepted, key retained; declared type gains refusal?: true widened (published payload gains a key)
same — input refusal: <non-true> accepted (stripped) refused at ['refusal'] narrowed on a spelling no in-repo producer emits (grep packages/**, apps/**: 0 emitters; the only refusal: key is SweepRefusal | null on a CLI plan object, sys-secret-orphan-sweep.ts:186) — same class as userMessage (#9934) and declaredCode (#9106), both shipped minor
packages/spec/api-surface/api.json ApiError (type)/ApiErrorSchema (const) :58-60, :344-346 identical (names only) unchanged
packages/spec/authorable-surface/api.json +api/ApiError:refusal :139, +api/EnhancedApiError:refusal :586 +2 rows; not in files[]
json-schema/api/{ApiError,EnhancedApiError}.json (shipped via files[], gitignored .gitignore:63) refusal: {type: boolean, const: true} per PR body NOT MEASURED by the reviewer (needs a build); CI Build Core + Type Check · source gates (runs check:authorable-surface/check:docs, lint.yml:4622-4700) green on this sha
registered error codes, conversions/registry.ts untouched

Clause-②: yes at PR body line 3 (line-start); check-widening-tells --declaration no fires T1 at contract.zod.ts:175 and errors.zod.ts:401 (exit 4), so yes is mechanically forced; --declaration yes exit 0; check-clause2-carriers --pair 17090 exit 0. Changeset: @objectstack/spec: minor, private unset → published. Offline gates on --base origin/main --head refs/pr-review/17090: check-changeset-no-major ✓ (level axis N/A offline; CI Check Changeset ✓ carries the PR-payload cross-check), check-adr-0087-registration ✓ "no declared-breaking changeset" (breakingDeclaration at :569-572 signals only on major / **BREAKING / BREAKING CHANGE / ! — none present, so no marker owed), check-empty-changeset ✓. No **BREAKING** banner required.

3. Enforcement / pins (ADR-0078 / ADR-0049)

Reader: none on this head, by ruling — see F1. Schema-level pins: 6 in contract.test.ts (:90-144, :737-755 through makeApiErrorSchema) and 1 in errors.test.ts (:166-191); no .skip/.only/.todo; no test pins the docblock. Structural ablation read: deleting the refusal line turns error.refusal into undefined (unknown key stripped) and safeParse({refusal:false}).success into true, so 5 contract pins + the errors pin go red and only the "absent by default" pin stays green — exactly the dev's recorded 6 failed | 87 passed of 93. The reviewer's own run is NOT MEASURED: node_modules is not installed in the review checkout and it did not install into the shared tree; CI Test Core (1/6–6/6 + rollup) is green on this exact sha. The TSDoc's sweeping claim "no site under packages/** composes a rewrapped error from a caught error's fields" survived wider probes than the PR's Object.assign survey (new XError(<caught>.message, status|statusCode|httpStatus: <caught>.…, {...err} into a throw): no hit outside the sandbox door (quickjs-runner.ts:288, closed list at :1250 verified) and the relay's own body composition (error-response.ts:2118/2249). hostImportError (types/src/node.ts:343) and protocol.ts:3144 compose fresh errors from their own fields.

4. Docs

content/docs/references/api/*.mdx — 11 generated pages, each gains the one row; errors.mdx:153 nested-shape summary also moves. Docblocks: both schemas. content/docs/releases/**: 0 edits. docs/adr/0112 not amended (would make the PR governed; #9934 set the same precedent) — noted by the PR, acceptable. F2 for the hand-written catalog page.

5. CI on head

38 check-runs on ba3d95a4: 33 success, 5 skipped, 0 failure, 0 in_progress. Skipped: Packed-tarball smoke (opt-in) ×2, Auto Label, Check PR Size, Console Pin Gate — opt-in/path filters, each with a successful sibling where one exists. Required floor Lint & Repo Gates ✓, TypeScript Type Check ✓; also Check Changeset ✓, Spec property liveness ✓, Governed Surface Queue Guard ✓, Part-of PR must not also close its card ✓. Head is 11 commits behind origin/main (3045b6faa27544b4); none of those commits touch any of the 17 files.

6. PR body accuracy

Verified: 17 files, +306/−1, 4 commits, merge-base 854639b3; 6+1 pins; +2 authorable rows; 11 pages; SANDBOX_ERROR_PASSTHROUGH at quickjs-runner.ts:1250; declaredServerFaultAnswer :574-588, gate :577-578, docblock :565-572; Fixes #16335 correct (all acceptance items land here; no closing keyword adjacent to #16146/#14656). Stale: F3 placeholder only.

Discharged

Card acceptance item Status Evidence
envelope schema declares the field with a stable TSDoc contract.zod.ts:93-180, errors.zod.ts:390-405; three-case table :105-109; #9934 reconciliation :141-165
api-surface baseline regenerated ✅ (vacuous, as the pre-reading predicted) api-surface/api.json byte-identical by construction (names only); the moving baseline authorable-surface/api.json regenerated (+2 rows)
changeset present .changeset/adr-0112-envelope-refusal-declaration.md, @objectstack/spec: minor
scope: no allow-list / no second table; default stays #5811/#5667 no ledger edits; packages/rest/**, packages/types/** untouched
dispatch constraint: reconcile with contract.zod.ts:77-83 or report a fork reconciled in TSDoc; fork named, not taken
{"pr":17090,"head":"ba3d95a4f3514243131a698f12589c23d49e6fcd","verdict":"PASS WITH FINDINGS","blocking":[],"clause2":"yes","semver_ok":true,"governed":false,"ci":"33 success / 5 skipped (opt-in, path filters) / 0 failure / 0 in_progress on head; required Lint & Repo Gates + TypeScript Type Check green"}

维护者速读

这条 PR 给 ADR-0112 错误信封加了一个可选键 refusal: true,让生产者声明「这个 5xx 是刻意拒绝,message 是写给调用方的」,从而在中继半(#16146)落地后不再被替换成 Internal server error;本 PR 只动 packages/spec,rest/types 一行未碰。已发布契约的移动是:两个信封 schema 与 makeApiErrorSchema 派生账本都多了一个键(true 保留、false 或非布尔拒收、缺省不变),authorable-surface 加两行,api-surface 按构造零差;Clause-②: yes@objectstack/spec: minor 在三条 changeset 门禁和 CI Check Changeset 上都对得上,无需 BREAKING 横幅或 ADR-0087 标记。无阻塞项;三条非阻塞发现里最要紧的是 F1:这个键在本 head 上没有读者,.describe() 文案(生成到 11 个参考页和 json-schema)用现在时说「边界会原样保留」,而在 #16146 落地前线上仍会扣住——失效方向是 fail-closed,无安全退化,但发布节奏必须把两半放在同一个 RC 里。CI 在该 sha 上 33 绿 5 跳过 0 红,两个 required job 均绿;head 落后 main 11 个提交但无文件重叠。席位下一步:修 F3 的占位符、决定 F2 走随手一行还是 docs 跟进卡、按 PASS 清双载体标签并入队,合并即刻把 #16146pm:blocked 解开并派发。


Generated by Claude Code

… tighten the survey sentence

Contract review on PR #17090 (B1): `@objectstack/rest` withholds a declared
5xx at two arms with byte-identical output — `declaredServerFaultAnswer`
(two callers: the `/data` classifier and the analytics door) and
`resolveErrorResponse`'s own 5xx passthrough arm, which every route reporting
through `handleRouteError` / `sendThrownError` reaches, the `/references` door
among them. The TSDoc and the changeset named only the first; both now name
both and state that #16146 must move both.

Also: the route-local patch is PR #16143 (not #16146, which retires it); the
rewrap survey now covers all of `packages/**` — 13 `Object.assign` sites, the
three copying off a caught error (`driver-sql`) copy `code`/`cause` and no
`status`, and the one in-place `message` rewrite (`domains/actions.ts`) is on
a `SandboxError` that cannot carry the flag; both `.describe()` strings carry
the relay-half caveat so the generated pages stop advertising a reader that
does not exist until #16146 lands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…f caveat)

os-regen-merge.sh step 2 took origin/main's side of the generated
`content/docs/references/api/protocol.mdx` (both sides moved it); the
pre-commit collection point asked for `gen:schema && gen:docs` on the merged
tree. The same run re-derives every page that inlines the envelope with the
`.describe()` relay-half clause added for the contract review's N1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…n issue id

`check:doc-authoring` refuses an issue id in `.describe()` prose — it
projects into customer-facing references and the shipped JSON schema, where
`#NNNN` resolves to nothing. The caveat stays in both describes, spelled as
the fact it states: until the REST withhold arms read the declaration, a
declared refusal is still withheld. Generated pages and schemas re-derived.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH

Copy link
Copy Markdown
Collaborator

Contract review at CONTRACT_REVIEW_TIERVerdict: PASS WITH FINDINGS, carries to the current head (delta re-review; audit reading; director seat, summon #18 segment 5, session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-09T12:5xZ)

PR #17090 · head e730560072f5eac052aae60d382546d94277d9bb (re-read at posting 12:54:43Z; unchanged since 12:26Z push, body edited 12:49Z) · reviewed 12:34Z–12:51Z · verdict of record: 5598904803 (PASS WITH FINDINGS on ba3d95a4f3).

  • Reviewed-by: isolated claude-fable-5-1 subagent, transcript-verified (136 harness model stamps, all claude-fable-5-1, zero residue; positive control 114 assistant / 70 user role tokens), adopted verbatim below.
  • Implemented-by: the domain:spec seat session_01MkQhmuuJAVDjmeWNixwDDH (os-bill) itself, branch of spec: ADR-0112 error envelope gains an explicit producer-side refusal declaration so a deliberate 5xx refusal can keep its caller-authored message (spec half of #16146) #16335. Distinct from this session ⇒ not a self-review by this seat; the implementer being the dispatching seat is the pair the prior verdict already recorded.
  • Reading for the seat: the accept set did not move — the delta is TSDoc, two .describe() strings, 66 regenerated reference rows, changeset prose, and one clean merge of main; F1 addressed (route B, id-less spelling after check:doc-authoring refused the first), F3 addressed, F2 (error-catalog.mdx still lacks refusal/userMessage/declaredCode) still open — one-line edit here or a docs-only card; F4 the patch-round report is on neither carrier. CI 36 green / 0 red on the head. Release stays the seat's act. ⛔ This seat cleared no carrier.

Head reviewed: e730560072f5eac052aae60d382546d94277d9bb (re-read 12:51Z; refs/pm-review/17090-r2 resolves to it). Prior verdict head: ba3d95a4f3514243131a698f12589c23d49e6fcd. Still draft; 8 commits; 17 files, +321/−1 vs merge-base 0da638cd (= base.sha). PR body edited 12:49:31Z with no push.

Delta summary (ba3d95a4..e7305600, PR-owned hunks only)

Commit Hunks (file:line on the new head) Judgment
6b47277b .changeset/adr-0112-envelope-refusal-declaration.md:17 — relay half now "the two withhold arms"; route-local patch attributed to PR #16143 prose only; grade unchanged
contract.zod.ts:125-127 — patch = PR #16143, retired by #16146 matches the body the prior verdict read; #16143 itself NOT re-verified
contract.zod.ts:132-142 — "Read once, at the withhold arms": declaredServerFaultAnswer and resolveErrorResponse's own 5xx arm; "#16146 must move BOTH" verified on origin/main: declaredServerFaultAnswer error-response.ts:574, callers only classifyDataError :1409 and the analytics route rest-server.ts:11190; resolveErrorResponse :1976, guard :2015-2020 keeps a declared 5xx band away from mapDataError, 5xx arm :2116-2124; entered from sendThrownError :1850 and handleRouteError :2439. Beyond the prior findings and material to #16146's scope, not to this PR's accept set
contract.zod.ts:157-160 — "only consumers are the two withhold arms" consistent with the above
contract.zod.ts:173-180 — survey: 13 Object.assign sites under packages/**; the 3 in driver-sql copy code/cause, no status; the one in-place message rewrite (domains/actions.ts) is on a SandboxError verified: 19 grep hits − 5 CHANGELOG − 1 comment = 13; sql-driver.ts sites copy only code/cause (head tree :12352/:15795/:16164); actions.ts:853 gated on innerMessage; SANDBOX_ERROR_PASSTHROUGH quickjs-runner.ts:1250 unchanged. Replaces the prior "no site … composes a rewrapped error" claim with the measured one — strictly more accurate
contract.zod.ts:187-193, errors.zod.ts:401-406.describe() gains the relay caveat, first spelled with #16146 F1 route B; the #16146 spelling went red (below)
57e6720a merge of origin/main 0da638cd (140 main-side files) see §merge
4e9a6c5a 11 content/docs/references/api/*.mdx regenerated (67/67) generated
e7305600 contract.zod.ts:189-191, errors.zod.ts:404-405 — caveat re-spelled without an issue id ("until the REST withhold arms read it, a declared refusal is still withheld"); 11 pages regenerated fixes the delta's own CI red; final describe text is true on this head

Published face vs the prior reading: the accept set did not move. contract.zod.ts with comments stripped is identical to ba3d95a4 except the describe string; errors.zod.ts likewise; contract.test.ts, errors.test.ts, authorable-surface/api.json byte-identical prior→r2. What moved on the published face is only text: the two .describe() strings (→ json-schema/api/{ApiError,EnhancedApiError}.json description, NOT MEASURED without a build; Build Core + Type Check · source gates green), the TSDoc (ships in src/**/*.zod.ts), 66 generated rows across 11 pages (0 stale spellings: git grep "REST withhold arms" hits all 11; relay half: #16146 / so boundaries keep it verbatim 0 hits), the changeset prose. The prior verdict's accept-set table, ledger reading (no liveness/api.json row owed) and api-surface zero-diff stand unchanged.

Merge (57e6720a): PR hunks byte-identical across it for 16/17 files (diff(B,6b47277b) vs diff(M2,merge) per file). protocol.mdx is the one exception: the merge kept the PR side whole (main's dashboards row change not carried — content/docs/references/** is regenerated whole under the os-regen driver), and 4e9a6c5a re-derived it. On the final head protocol.mdx differs from origin/main by the 3 +refusal rows only and its dashboards row equals main's; check:docs (lint.yml:4733, inside Type Check · source gates) green on the head.

Prior findings status

Clause-② reading (re-derived; unchanged)

Clause-②: yes at body line 3. check-widening-tells --declaration no on origin/main...r2 fires T1 at contract.zod.ts:187 and errors.zod.ts:401 (exit 4; line moved from 175 by TSDoc growth), --declaration yes exit 0. check-clause2-carriers --pair 17090 exit 0 ("both carriers agree"). @objectstack/spec: minor still correct (text-only delta). Offline on --base origin/main --head refs/pm-review/17090-r2: check-adr-0087-registration 0, check-changeset-no-major 0, check-empty-changeset 0; CI Check Changeset success ×2.

Governed surface / protocol label

Non-governed: GOVERNED_SURFACES (check-governed-merges.mjs:793) = docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md; none in the 17 files (main's merge brought such commits into the branch history, but origin/main...r2 is the 17 files). Governed Surface Queue Guard green. No protocol:* label and none expected: .github/labeler.yml maps protocol:* only to packages/spec/src/{data,ui,system,ai}/**; src/api/** is unmapped. Labels on head: documentation, size/m, tests, tooling, needs:contract-review; card: enhancement, priority:p2, pm:dispatched, domain:spec, needs:contract-review — both carriers still carry the gate.

CI on head

40 check-runs on e7305600 at 12:51Z: 36 success, 4 skipped, 0 failure, 0 in progress. Skipped: Console Pin Gate, Packed-tarball smoke (opt-in), and the 12:49Z edited-event re-runs of Auto Label / Check PR Size (each has a successful push-event sibling). Required floor Lint & Repo Gates ✓ (12:49:52Z), TypeScript Type Check ✓; Test Core rollup + 6/6, Build Core, Type Check · source gates, Spec property liveness, Part-of PR must not also close its card ✓. Intermediate heads: 4e9a6c5a Lint & Repo Gates failed at step 53 "Doc/skill authoring guard" on the #16146 ids at contract.zod.ts:189 / errors.zod.ts:404 — caused by the delta's first spelling, fixed by e7305600 (step 53 ✓ on head). 6b47277b TypeScript Type Check failure is the aggregator refusing four cancelled lanes after the head moved 17 s later — not a delta defect.

New findings

F4 (non-blocking) — no patch-round report on either carrier: zero comments on #17090 or #16335 after the director's 08:36/08:38Z notes, and the seat's adoption is unposted. The body now claims re-runs on e73056007 (93/93, ablation at ba3d95a4 with the schema line unchanged since, check:generated 15/15, 103 derived gates) but the card carries none of it. CI on the head is the reading of record and is fully green, so this does not move the verdict; the seat should post the round report (or point the card at the body) when it adopts and clears.

F5 (non-blocking, cosmetic) — body Reconciliation §2 cites the driver-sql sites at :12354/:15797/:16166; on the head tree the Object.assign lines are :12352/:15795/:16164 (the cited numbers are each site's cause: line). Same three sites; the TSDoc cites file names only, which is the durable spelling.

Acceptance notes


Generated by Claude Code

… — the third is `@objectstack/runtime`'s dispatcher exit

The published TSDoc, both `.describe()` caveats and the changeset treated
the two `@objectstack/rest` arms as the closed set. `errorResponseBase`
(`packages/runtime/src/dispatcher-plugin.ts`, gated on
`serverFaultProvenance(thrown) === 'declared'`) withholds every declared
5xx too, never consults `declaredServerFaultAnswer`, is mounted by
`objectstack serve`, and emits the very envelope this field lands on.
The prose now names it, hands the runtime exit to #16146's sub-issue
#17153, and:

- drops the "13" census count, narrows the copy-off-a-caught-error claim
  to the `Object.assign` shape, and names the two overlay-delete rewraps
  in `metadata-protocol/src/protocol.ts` that copy `status` and carry
  `code`/`userMessage` through `carry*` helpers (⛔ no `carryRefusal`);
- notes the analytics dataset door calls arm 1 bare (no
  `withDeclaredUserMessage`), so the REST arms are not byte-identical
  when `userMessage` is declared;
- replaces the `/references` routing attribution: the throw spells
  `status`, so it takes `resolveErrorResponse`'s status passthrough into
  that function's own 5xx arm; a `statusCode` spelling falls to
  `mapDataError` and arm 1;
- qualifies the table's second row as the rule at the three arms, not a
  platform-wide invariant (the heuristic-only doors read no declaration);
- pins the shipped JSON Schema's `const: true` on both envelopes through
  the generator's own `toJSONSchema` options;
- adds the `refusal?: true` line to the hand-written `EnhancedApiError`
  excerpt in `content/docs/api/error-catalog.mdx`;
- regenerates the 66 reference rows for the id-less caveat that no
  longer says "REST".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…he caught error, so say "and/or"

The sentence grouped it with the `driver-sql` rewraps as copying "`code` and
`cause`"; the objectql site sets `code` as a literal and copies only `cause`.
The claim that matters — none of them copies `status` — is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

3 participants